Skip to contentNode.js
2776. 转换回调函数为 Promise 函数
概述
许多早期的 Node.js 接口和第三方库采用回调风格传递异步结果。将这些函数转换为返回 Promise 的函数,可以统一使用 async/await 控制流。这个转换过程通常被称为“promisify”。LeetCode 2776 要求实现一个基本的 promisify,将给定的回调风格函数包装为 Promise 版本。
基本概念
Node.js 的错误优先回调
在 Promise 成为语言标准之前,Node.js 生态形成了一种约定:回调函数接收两个参数,第一个参数是错误对象(或 null),第二个参数是操作结果。形如:
ts
function nodeStyleFunction(arg1, arg2, callback) {
// 异步操作完成后:
if (error) {
callback(error);
} else {
callback(null, result);
}
}promisify 就是这种约定与 Promise 之间的桥梁。Node.js 内置的 util.promisify() 实现了相同的职责,并处理了更多边界情况。
LeetCode 题目中的回调签名与上述约定不同——其回调参数顺序为
(data, error),而不是(error, data)。在阅读下文时需要区分这两种模式。
工作原理
promisify 返回一个新函数。新函数内部创建 Promise,在 Promise 构造器里调用原始函数 fn,并传入一个经过包装的回调。包装后的回调决定 Promise 的状态:
- 若回调收到错误(题目约定下检查第二个参数),调用
reject。 - 若未收到错误,调用
resolve并传入数据。
原始函数签名在题目中定义为:
ts
type CallbackFn = (
next: (data: number, error: string) => void,
...args: number[]
) => void即原始函数将包装后的回调作为第一个参数,其余参数随后传入。这与常见的 (arg1, arg2, callback) 模式不同,因此 promisify 的实现需要匹配该调用顺序。
基本用法
实现时需要在 reject 之后立即返回,否则代码会继续执行 resolve,导致意图模糊,虽然 Promise 状态不会改变,但可能引发副作用。
ts
type CallbackFn = (
next: (data: number, error: string) => void,
...args: number[]
) => void;
type Promisified = (...args: number[]) => Promise<number>;
function promisify(fn: CallbackFn): Promisified {
return (...args) =>
new Promise((resolve, reject) => {
fn((data, err) => {
if (err) {
reject(err);
return; // 阻止后续 resolve 执行
}
resolve(data);
}, ...args);
});
}使用时,将一个符合签名的函数传入即可:
ts
const asyncFunc = promisify(callback => callback(42));
asyncFunc().then(console.log); // 42如果回调传递了错误字符串,Promise 会被拒绝:
ts
const failingFunc = promisify(callback => callback(0, 'something wrong'));
failingFunc().catch(console.error); // 'something wrong'示例
适配 Node 错误优先风格的 promisify
实际项目中更常遇到的是错误优先回调。此时 promisify 的实现需要改为将原始参数放在回调之前,并按照 (err, result) 判断:
ts
function nodePromisify<T>(
fn: (...args: any[]) => void
): (...args: any[]) => Promise<T> {
return (...args) =>
new Promise((resolve, reject) => {
fn(...args, (err: any, result: T) => {
if (err) {
reject(err);
return;
}
resolve(result);
});
});
}使用这个版本模拟一个基于回调的读取操作:
ts
function readConfig(path: string, callback: (err: Error | null, data?: string) => void) {
setTimeout(() => {
if (path === '') {
callback(new Error('path is required'));
} else {
callback(null, 'server.port=8080');
}
}, 10);
}
const readConfigPromise = nodePromisify<string>(readConfig);
(async () => {
try {
const data = await readConfigPromise('/etc/app.conf');
console.log(data); // 'server.port=8080'
} catch (e) {
console.error(e);
}
})();注意点
reject后必须终止执行
包装的回调中一旦调用了reject,需要立刻return,否则代码会继续向下执行resolve。Promise 只会以第一次决议为准,但额外的resolve调用虽然不会改变状态,却可能引起副作用(例如修改外部变量)或使代码意图模糊。回调参数顺序差异
题目的回调签名是(data, error),而 Node.js 生态的标准是(error, data)。若用题目的实现去封装符合 Node 约定的函数,会得到颠倒的结果值。需要根据实际函数签名调整promisify的实现,或者直接使用 Node.js 内置的util.promisify。Promise 的单次决议特性
如果原始函数多次调用回调,只有第一次调用会决定 Promise 状态,后续调用会被忽略。promisify假设回调仅被调用一次,这一隐式契约要求被包装的函数必须遵守“回调只调用一次”的规则。
Node.js util.promisify 的扩展
util.promisify() 在处理多参数回调时做了额外工作。以 fs.readFile 的原生回调为例,它接收 (err, data),因此可以直接 promisify。但对于某些 API(如 child_process.exec),回调除了 error 和 stdout 外还会传递 stderr。util.promisify 会将超过两个的参数放入一个对象并作为 Promise 的结果返回。
js
const util = require('util');
const child_process = require('child_process');
const execPromise = util.promisify(child_process.exec);
(async () => {
const { stdout, stderr } = await execPromise('ls -la');
console.log(stdout);
})();如果内置行为不满足需求,可以让原始函数通过 util.promisify.custom 符号自定义 promisify 后的行为:
js
const fs = require('fs');
const util = require('util');
function readFileCustom(path, callback) {
return fs.readFile(path, callback);
}
readFileCustom[util.promisify.custom] = (path) => {
return new Promise((resolve, reject) => {
fs.readFile(path, (err, data) => {
if (err) reject(err);
else resolve(data.toString('utf-8'));
});
});
};
const readFilePromise = util.promisify(readFileCustom);